package internal

import (
	"context"
	"errors"
	"fmt"
	"go/ast"
	"go/format"
	"go/token"
	"go/types"
	"os"
	"path/filepath"
	"strings"

	"golang.org/x/tools/go/packages"
)

// GenerateResult stores the result for a package from a call to Generate.
type GenerateResult struct {
	// PkgPath is the package's PkgPath.
	PkgPath string
	// OutputPath is the path where the generated output should be written.
	// May be empty if there were errors.
	OutputPath string
	// Content is the gofmt'd source code that was generated. May be nil if
	// there were errors during generation.
	Content []byte
	// Errs is a slice of errors identified during generation.
	Errs []error
}

// Commit writes the generated file to disk.
func (gen GenerateResult) Commit() error {
	if len(gen.Content) == 0 {
		return nil
	}

	var defMode uint32 = 0o664

	return os.WriteFile(gen.OutputPath, gen.Content, os.FileMode(defMode))
}

// GenerateOptions holds options for Generate.
type GenerateOptions struct {
	// Header will be inserted at the start of each generated file.
	Header           []byte
	PrefixOutputFile string
	Tags             string
}

// Generate may return one or more errors if it failed to load the packages.
func Generate(
	ctx context.Context,
	pwd string,
	env []string,
	patterns []string,
	opts *GenerateOptions,
) ([]GenerateResult, []error) {
	if opts == nil {
		opts = &GenerateOptions{}
	}

	pkgs, errs := LoadPackages(ctx, pwd, env, opts.Tags, patterns)
	if len(errs) > 0 {
		return nil, errs
	}

	generated := make([]GenerateResult, len(pkgs))
	for idx, pkg := range pkgs {
		generated[idx].PkgPath = pkg.PkgPath

		outDir, err := detectOutputDir(pkg.GoFiles)
		if err != nil {
			generated[idx].Errs = append(generated[idx].Errs, err)

			continue
		}

		generated[idx].OutputPath = filepath.Join(outDir, opts.PrefixOutputFile+"bind_gen.go")
		gen := NewGen(ctx, pkg)

		injectorFiles, errs := generateBinds(gen, pkg)
		if len(errs) > 0 {
			generated[idx].Errs = errs

			continue
		}

		copyNonBindDecls(gen, injectorFiles, pkg.TypesInfo)

		goSrc := gen.Frame(opts.Tags)
		if len(opts.Header) > 0 {
			goSrc = append(opts.Header, goSrc...)
		}

		fmtSrc, err := format.Source(goSrc)
		if err != nil {
			// This is likely a bug from a poorly generated source file.
			// Add an error but also the unformatted source.
			generated[idx].Errs = append(generated[idx].Errs, err)
		} else {
			goSrc = fmtSrc
		}

		generated[idx].Content = goSrc
	}

	return generated, nil
}

func detectOutputDir(paths []string) (string, error) {
	if len(paths) == 0 {
		return "", ErrNoFiles
	}

	dir := filepath.Dir(paths[0])
	for _, path := range paths[1:] {
		if dir2 := filepath.Dir(path); dir2 != dir {
			return "", fmt.Errorf("found conflicting directories %q and %q", dir, dir2)
		}
	}

	return dir, nil
}

// copyNonBindDecls copies any non-injector declarations from the
// given files into the generated output.
func copyNonBindDecls(gen *Gen, files []*ast.File, info *types.Info) {
	for _, file := range files {
		name := filepath.Base(gen.pkg.Fset.File(file.Pos()).Name())
		first := true

		for _, decl := range file.Decls {
			switch decl := decl.(type) {
			case *ast.FuncDecl:
				// OK to ignore error, as any error cases should already have
				// been filtered out.
				if buildCall, _ := findBuild(info, decl); buildCall != nil {
					continue
				}
			case *ast.GenDecl:
				if decl.Tok == token.IMPORT {
					continue
				}
			default:
				continue
			}

			if first {
				gen.Pf("// Source form %s:\n\n", name)

				first = false
			}

			gen.writeAST(info, decl)
			gen.Pf("\n\n")
		}
	}
}

// generateBinds generates the binds for a given package.
func generateBinds(gen *Gen, pkg *packages.Package) (injectorFiles []*ast.File, _ []error) {
	injectorFiles = make([]*ast.File, 0, len(pkg.Syntax))
	errCtr := new(errorCollector)

	for _, syn := range pkg.Syntax {
		for _, decl := range syn.Decls {
			fun, isOk := decl.(*ast.FuncDecl)
			if !isOk {
				continue
			}

			buildCall, err := findBuild(pkg.TypesInfo, fun)
			if errors.Is(err, ErrInvalidValue) {
				continue
			}

			if err != nil {
				errCtr.add(err)

				continue
			}

			if buildCall == nil {
				continue
			}

			if len(injectorFiles) == 0 || injectorFiles[len(injectorFiles)-1] != syn {
				name := filepath.Base(gen.pkg.Fset.File(syn.Pos()).Name())
				gen.Pf("// Bind form %s:\n\n", name)

				injectorFiles = append(injectorFiles, syn)
			}

			sig := pkg.TypesInfo.ObjectOf(fun.Name).Type().(*types.Signature)

			if errs := gen.bind(fun.Pos(), fun.Name.Name, getIgnores(fun), sig, fun.Doc); len(errs) > 0 {
				errCtr.add(errs...)

				continue
			}
		}

		for _, impt := range syn.Imports {
			if impt.Name != nil && impt.Name.Name == "_" {
				gen.anonImports[impt.Path.Value] = true
			}
		}
	}

	if len(errCtr.errors) > 0 {
		return nil, errCtr.errors
	}

	return injectorFiles, nil
}

func getIgnores(fun *ast.FuncDecl) []string {
	ret := []string{}

	for _, stmt := range fun.Body.List {
		exprStmt, isStmt := stmt.(*ast.ExprStmt)
		if !isStmt {
			continue
		}

		exprX, isStmt := exprStmt.X.(*ast.CallExpr)
		if !isStmt {
			continue
		}

		for _, expr := range exprX.Args {
			call, ok := expr.(*ast.CallExpr)
			if !ok {
				break
			}

			for _, arg := range call.Args {
				str, ok := arg.(*ast.BasicLit)
				if !ok {
					break
				}

				if str.Kind == token.STRING {
					ret = append(ret, strings.Trim(str.Value, "\""))
				}
			}
		}
	}

	return ret
}
